Micron Document
🎖️GitЯра🎖️

Commit b7cdc26a6e2b9076f73a20983a8ecf3c5287a784


Parents : 1e6a423
Author : James Rich <james.a.rich@gmail.com>
Date : 2026-05-18T14:58:43-05:00

fix(map): pulse indicator fires on new packet, not permanent online status

Change the pulsing ring from showing for all online nodes to only
nodes heard within the last 5 seconds. This correctly indicates when
a new packet arrives rather than acting as a static online badge.

- Add 'recently_heard' boolean property to GeoJSON features
- Use Clock.System.now().epochSeconds with periodic tick (1s) to
expire stale pulse states
- Filter pulse layer on 'recently_heard' instead of 'is_online'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Changes
Diff

diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
index aae47f8989..fadaa46f51 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
@@ -26,14 +26,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.asString
@@ -107,6 +111,8 @@ private const val HILLSHADE_EXAGGERATION = 0.5f
private const val PULSE_DURATION_MS = 1500
private const val PULSE_MAX_RADIUS_DP = 14f
private const val PULSE_START_OPACITY = 0.5f
+private const val PULSE_WINDOW_SECONDS = 5L
+private const val PULSE_TICK_INTERVAL_MS = 1000L
/** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */
private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png")
@@ -202,14 +208,25 @@ private fun NodeMarkerLayers(
onNodeClick: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
- val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
+
+ // Tick current time to expire pulse animations after PULSE_WINDOW_SECONDS
+ var nowEpochSeconds by remember { mutableStateOf(Clock.System.now().epochSeconds) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(PULSE_TICK_INTERVAL_MS)
+ nowEpochSeconds = Clock.System.now().epochSeconds
+ }
+ }
+
+ val featureCollection =
+ remember(nodes, myNodeNum, nowEpochSeconds) { nodesToFeatureCollection(nodes, myNodeNum, nowEpochSeconds) }
// Read M3 semantic colors for map layers (recomposes on theme change)
val clusterColor = MaterialTheme.colorScheme.primary
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val clusterLabelColor = MaterialTheme.colorScheme.onPrimary
- // Pulsing ring animation for online nodes
+ // Pulsing ring animation for recently-heard nodes
val pulseTransition = rememberInfiniteTransition(label = "node-pulse")
val pulseProgress by
pulseTransition.animateFloat(
@@ -261,13 +278,13 @@ private fun NodeMarkerLayers(
textSize = const(1.2f.em),
)
- // Pulsing ring behind online nodes — animated radius expanding outward with fading opacity
+ // Pulsing ring behind recently-heard nodes — indicates new packet received
val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp
val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress)
CircleLayer(
id = "node-pulse-ring",
source = nodesSource,
- filter = feature["is_online"].convertToBoolean(),
+ filter = feature["recently_heard"].convertToBoolean(),
radius = const(pulseRadius),
color = const(OnlineStrokeColor),
opacity = const(pulseOpacity),

diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
index e4e5afdd4c..d0d780e4b7 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
@@ -29,15 +29,24 @@ import org.meshtastic.core.model.Node
private const val MIN_PRECISION_BITS = 10
private const val MAX_PRECISION_BITS = 19
+private const val RECENTLY_HEARD_WINDOW_SECONDS = 5
/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */
-internal fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
+internal fun nodesToFeatureCollection(
+ nodes: List<Node>,
+ myNodeNum: Int? = null,
+ nowEpochSeconds: Long = 0L,
+): FeatureCollection<Point, JsonObject> {
val features =
nodes.mapNotNull { node ->
val pos = node.validPosition ?: return@mapNotNull null
val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
val colors = node.colors
+ val recentlyHeard =
+ nowEpochSeconds > 0L &&
+ node.lastHeard > 0 &&
+ (nowEpochSeconds - node.lastHeard) <= RECENTLY_HEARD_WINDOW_SECONDS
val props = buildJsonObject {
put("node_num", node.num)
put("short_name", node.user.short_name)
@@ -46,6 +55,7 @@ internal fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null)
put("is_favorite", node.isFavorite)
put("is_my_node", node.num == myNodeNum)
put("is_online", node.isOnline)
+ put("recently_heard", recentlyHeard)
put("battery_level", node.batteryLevel ?: -1)
put("hops_away", node.hopsAway)
put("via_mqtt", node.viaMqtt)

Served by rngit 1.5.0 - Generated in 0.08s